Popular Searches
Popular Course Categories
Popular Courses

Integrating Coroutines with Retrofit and Room

Integrating Coroutines with Retrofit and Room

Coroutines and Asynchronous Programming

Integrating Coroutines with Retrofit and Room

Course: Android App Development

Topic: Integrating Kotlin Coroutines with Retrofit and Room Database

Course Link: Android App Development Course – JustAcademy

Demo Link: Register for a Course Demo

Website: JustAcademy Official Website


1. Introduction

Modern Android applications frequently retrieve information from remote servers and store it locally for faster access and offline usage. For example, a shopping application may download products from an API, save them in a local database, and display the saved products even when the internet connection is unavailable.

Three important technologies make this workflow easier:

  • Kotlin Coroutines: Execute asynchronous operations without blocking the main thread.
  • Retrofit: Communicate with REST APIs and convert HTTP responses into Kotlin objects.
  • Room: Provide an abstraction over SQLite for storing and querying structured local data.

Integrating these technologies creates an efficient data flow in which Retrofit retrieves information from a server, Room stores it locally, and Kotlin Coroutines coordinate the operations without freezing the user interface.

2. Learning Objectives

After completing this topic, you should be able to:

  • Understand the roles of Coroutines, Retrofit, and Room.
  • Define suspend functions in Retrofit service interfaces.
  • Build Room entities, DAOs, and database classes.
  • Combine remote API calls with local database operations.
  • Use Flow to observe database changes.
  • Implement loading, success, and error states.
  • Apply repository and ViewModel architecture.
  • Support offline-first data access and cancellation.

3. Understanding the Three Technologies

3.1 Kotlin Coroutines

Coroutines are Kotlin's mechanism for writing asynchronous code in a sequential and readable style. A coroutine can suspend while waiting for a network response or database operation, allowing other work to continue.

Important coroutine concepts include:

  • suspend: Marks a function that can suspend and resume.
  • viewModelScope: Provides a lifecycle-aware coroutine scope for ViewModels.
  • launch: Starts a coroutine when no return value is required.
  • async: Starts a coroutine that produces a result through Deferred.
  • withContext: Switches coroutine context when an operation requires a different dispatcher.
  • Flow: Represents an asynchronous stream of values.

3.2 Retrofit

Retrofit is a type-safe HTTP client commonly used to call REST APIs in Android applications. It maps interface methods to HTTP requests and can convert JSON responses into Kotlin data classes through a configured converter.

Common Retrofit annotations include:

Annotation Purpose Example
@GET Retrieve information @GET("products")
@POST Create or submit information @POST("products")
@PUT Replace a resource @PUT("products/{id}")
@PATCH Partially update a resource @PATCH("products/{id}")
@DELETE Delete a resource @DELETE("products/{id}")
@Path Insert a value into the URL path @Path("id")
@Query Add a query parameter @Query("page")

3.3 Room Database

Room is an Android persistence library that simplifies working with SQLite. It provides compile-time SQL query validation, entity mapping, and integration with Coroutines and Flow.

  • Entity: Defines a database table.
  • DAO: Defines database operations and queries.
  • RoomDatabase: Provides access to the database and its DAOs.
  • Flow: Emits updated query results when relevant database tables change.
  • Suspend DAO functions: Perform one-shot reads and writes asynchronously.

Room does not allow ordinary database queries to block the main thread. Coroutine-compatible DAO methods help keep database work asynchronous.

4. Why Integrate Coroutines with Retrofit and Room?

Using these technologies together helps separate networking, persistence, and user-interface responsibilities.

Requirement Technology Benefit
Fetch remote data Retrofit Simplifies HTTP communication.
Coordinate asynchronous tasks Coroutines Readable asynchronous execution.
Save data locally Room Persistent structured storage.
Observe saved data Room Flow Automatically emits updated query results.
Manage UI state ViewModel and StateFlow Separates UI presentation from data operations.

5. Understanding the Data Flow

A common architecture follows this sequence:

  1. The ViewModel requests product data from the repository.
  2. The repository calls a Retrofit service using a suspend function.
  3. Retrofit sends an HTTP request and returns the converted response.
  4. The repository saves the received products through a Room DAO.
  5. Room updates the local database.
  6. The DAO's Flow emits the latest database contents.
  7. The ViewModel exposes those contents to the UI.
Android UI
    |
    v
ViewModel
    |
    v
Repository
    |                         |
    v                         v
Retrofit API               Room DAO
    |                         |
    v                         v
Remote Server             Local Database
                              |
                              v
                         Flow<List<Product>>
                              |
                              v
                         ViewModel / UI

Important: A repository can expose Room's Flow as the primary source of data while refreshing it from the server. This makes the UI observe local data instead of depending directly on the timing of network requests.

6. Project Setup and Dependencies

Create an Android project using Kotlin. Add compatible versions of Retrofit, Room, Kotlin Coroutines, Lifecycle ViewModel, and the JSON converter you plan to use.

The following example uses Retrofit with Gson and the Room 2.x dependency family. Use compatible versions from your project's version catalog or official release documentation rather than mixing incompatible versions.

dependencies {
    // Kotlin Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:<compatible-version>")

    // Lifecycle ViewModel and lifecycle-aware collection
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:<compatible-version>")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:<compatible-version>")

    // Retrofit and Gson converter
    implementation("com.squareup.retrofit2:retrofit:<compatible-version>")
    implementation("com.squareup.retrofit2:converter-gson:<compatible-version>")

    // Room 2.x
    implementation("androidx.room:room-runtime:<compatible-version>")
    implementation("androidx.room:room-ktx:<compatible-version>")
    ksp("androidx.room:room-compiler:<compatible-version>")
}

Apply and configure the Kotlin Symbol Processing (KSP) plugin using a version compatible with your Kotlin toolchain. If your project uses Room 3.x, follow its current setup instructions because the package names, artifact coordinates, and build configuration differ from Room 2.x.

Official references:

7. Configure Internet Permission

For applications that access a remote API, declare internet permission in the Android manifest.

<uses-permission android:name="android.permission.INTERNET" />

Place this permission directly inside the manifest's root <manifest> element, outside the <application> element. HTTPS should be preferred for production API communication.

8. Create the Product Data Model

Assume the API returns products with an identifier, title, description, and price. The network response model represents the server's JSON structure, while the Room entity represents the local database table.

8.1 Network response model

data class ProductDto(
    val id: Int,
    val title: String,
    val description: String,
    val price: Double
)

This model is used by Retrofit to deserialize JSON. Its property names and types must match the API response or be configured with appropriate serialization annotations.

8.2 Room entity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "products")
data class ProductEntity(
    @PrimaryKey
    val id: Int,
    val title: String,
    val description: String,
    val price: Double
)

The @Entity annotation identifies the class as a Room table. The @PrimaryKey annotation ensures each stored product has a unique identifier.

8.3 Convert a network model into an entity

fun ProductDto.toEntity(): ProductEntity {
    return ProductEntity(
        id = id,
        title = title,
        description = description,
        price = price
    )
}

Keeping network and database models separate makes it easier to handle differences between API fields and local persistence requirements.

9. Create the Retrofit API Interface

Define a service interface for the remote endpoint. The example uses a placeholder base URL; replace it with the actual base URL of your API.

import retrofit2.http.GET

interface ProductApi {
    @GET("products")
    suspend fun getProducts(): List<ProductDto>
}

The suspend keyword allows Retrofit to execute the request through its coroutine integration. The function returns the decoded response directly rather than requiring a traditional callback.

For this suspend-based approach, you generally do not need a separate RxJava call adapter. Retrofit's JSON converter handles response deserialization.

10. Configure the Retrofit Client

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitClient {
    private const val BASE_URL = "https://api.example.com/"

    val productApi: ProductApi by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(ProductApi::class.java)
    }
}

Replace https://api.example.com/ with the real API base URL. Retrofit requires a valid base URL, normally ending in a slash. Do not use the placeholder in a production application.

For larger projects, provide Retrofit through dependency injection so services can be configured and tested more easily.

11. Create the Room DAO

The Data Access Object defines how the application reads, inserts, and replaces products in the local database.

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface ProductDao {
    @Query("SELECT * FROM products ORDER BY title ASC")
    fun observeProducts(): Flow<List<ProductEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertProducts(products: List<ProductEntity>)

    @Query("DELETE FROM products")
    suspend fun deleteAllProducts()

    @Query("SELECT * FROM products WHERE id = :productId")
    suspend fun getProductById(productId: Int): ProductEntity?
}

Here, observeProducts() returns a Flow that emits database query results whenever relevant table changes occur. The insert, delete, and single-item lookup functions are suspend functions for one-shot operations.

Using OnConflictStrategy.REPLACE updates an existing row with the same primary key. Be aware that replacement semantics can matter when tables have foreign keys or related records.

12. Create the Room Database

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [ProductEntity::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun productDao(): ProductDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "products_database"
                ).build().also { INSTANCE = it }
            }
        }
    }
}

This database contains a single products table. The singleton pattern avoids creating multiple database instances unnecessarily. For production applications, configure schema export and migration handling as part of the project's database strategy.

13. Build the Repository

The repository coordinates the remote API and the local database. It provides a consistent data-access interface to the ViewModel.

import kotlinx.coroutines.flow.Flow

class ProductRepository(
    private val api: ProductApi,
    private val productDao: ProductDao
) {
    val products: Flow<List<ProductEntity>> =
        productDao.observeProducts()

    suspend fun refreshProducts() {
        val remoteProducts = api.getProducts()
        val entities = remoteProducts.map { it.toEntity() }
        productDao.insertProducts(entities)
    }

    suspend fun getProductById(id: Int): ProductEntity? {
        return productDao.getProductById(id)
    }
}

The repository exposes local products as a Flow and defines a refresh operation. When the server response is saved to Room, the database query emits the updated list and observers receive the latest data.

This example deliberately does not clear the table before inserting the response. Replacing rows by primary key avoids briefly removing all locally cached products if a refresh is successful but the server returns only a subset. If the endpoint returns a complete authoritative snapshot, deletion and insertion can instead be performed together in a carefully designed transaction.

14. Run Network and Database Operations from the ViewModel

The ViewModel starts a refresh using viewModelScope. It exposes UI state so the screen can show loading indicators and error messages without directly managing network calls.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

data class ProductUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    val products = repository.products

    private val _uiState = MutableStateFlow(ProductUiState())
    val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()

    fun refresh() {
        viewModelScope.launch {
            _uiState.value = ProductUiState(isLoading = true)

            try {
                repository.refreshProducts()
                _uiState.value = ProductUiState(isLoading = false)
            } catch (e: kotlinx.coroutines.CancellationException) {
                throw e
            } catch (e: Exception) {
                _uiState.value = ProductUiState(
                    isLoading = false,
                    errorMessage = e.message ?: "Unable to refresh products."
                )
            }
        }
    }
}

The ViewModel starts the refresh operation and updates the loading and error state. The observed product Flow remains the source of displayed product data. The ViewModel needs a suitable factory or dependency injection configuration to receive the repository instance.

Why rethrow cancellation? Coroutine cancellation is a normal lifecycle mechanism. Treating cancellation as an ordinary application error can produce misleading UI messages or interfere with structured concurrency.

15. Collect Database Data in the UI

With a traditional Android Views-based screen, collect the product Flow using a lifecycle-aware scope.

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(
        androidx.lifecycle.Lifecycle.State.STARTED
    ) {
        viewModel.products.collect { products ->
            // Submit products to the adapter.
            // Example: productAdapter.submitList(products)
        }
    }
}

In a Fragment, use viewLifecycleOwner so collection follows the lifecycle of the view rather than the Fragment object alone. In Jetpack Compose, use lifecycle-aware collection such as collectAsStateWithLifecycle() when available in your project.

Because Room emits new results when the products table changes, the UI does not need to manually reload the database after every successful insert.

16. Understanding the Complete Request Lifecycle

  1. The user opens the product screen.
  2. The ViewModel begins observing Room's product Flow.
  3. The app may immediately display any products already stored locally.
  4. The ViewModel triggers refresh().
  5. Retrofit sends a GET request to the server.
  6. The server responds with JSON.
  7. Retrofit converts JSON into a list of ProductDto objects.
  8. The repository converts DTOs to ProductEntity objects.
  9. The DAO saves the entities in Room.
  10. Room emits the updated product list.
  11. The UI displays the latest database contents.
  12. If the request fails, cached data can remain visible while the UI presents an appropriate error or retry option.

17. Handling Errors Correctly

Network and persistence operations can fail for different reasons. Handle expected failures appropriately and avoid hiding useful diagnostic information during development.

Error Type Possible Cause Recommended Handling
Network failure Device offline or connection interrupted Keep cached data and provide retry feedback.
HTTP error Server returns 404, 401, or 500 Inspect the HTTP status and map it to a useful UI message.
JSON conversion error Unexpected response structure or data type Validate API models and handle parsing failures.
Database error SQL issue or schema mismatch Review DAO queries, entities, migrations, and logs.
Coroutine cancellation ViewModel or parent scope is cancelled Rethrow cancellation and allow cleanup.
Authentication error Missing or expired credentials Handle authentication through the application's security flow.

For a more refined implementation, distinguish HTTP exceptions, connectivity errors, serialization errors, and database errors in the repository or a dedicated error-mapping layer.

18. Implement an Offline-First Strategy

An offline-first approach treats the local database as the primary source observed by the UI. Network requests refresh the database rather than providing data directly to the screen.

Advantages

  • Previously saved information remains available without internet access.
  • The UI can render cached data quickly.
  • Network refreshes automatically update the observed list.
  • Data access is easier to test because the UI observes a stable local source.
  • Transient network failures do not necessarily make the entire screen empty.

Important design decisions

  • Define when a refresh should occur, such as screen entry, manual refresh, or a scheduled task.
  • Decide whether the API returns a full snapshot or partial updates.
  • Use transactions where multiple database operations must succeed together.
  • Consider timestamps or synchronization metadata if the app must track data freshness.
  • Define how local edits and server updates are reconciled.

19. Using a Transaction for Related Database Changes

When a refresh requires multiple database operations, a transaction helps prevent observers from seeing an inconsistent intermediate state. For example, if the API returns the complete authoritative list, you may replace the entire table in a transaction.

import androidx.room.withTransaction

class ProductSyncRepository(
    private val api: ProductApi,
    private val database: AppDatabase
) {
    private val productDao = database.productDao()

    suspend fun replaceAllProducts() {
        val remoteProducts = api.getProducts()
        val entities = remoteProducts.map { it.toEntity() }

        database.withTransaction {
            productDao.deleteAllProducts()
            productDao.insertProducts(entities)
        }
    }
}

This example is suitable only when the response represents the complete set of products that should exist locally. Do not delete the entire local table when the server response is paginated, filtered, or incomplete. Also consider whether deletion and insertion should be skipped when the incoming list is empty because of an unexpected API response.

20. Should withContext(IO) Be Used for Retrofit and Room?

Retrofit suspend service methods and Room suspend DAO methods already provide asynchronous integration. In a typical implementation, you do not need to wrap every call in withContext(Dispatchers.IO).

Use withContext(Dispatchers.IO) when your own code performs blocking I/O that does not already provide a coroutine-aware API. Use Dispatchers.Default for CPU-intensive work when appropriate, such as expensive calculations or transformations.

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

suspend fun readLegacyBlockingFile(): String {
    return withContext(Dispatchers.IO) {
        // Call a genuinely blocking file API here.
        "File contents"
    }
}

Do not assume that adding suspend alone makes a blocking function non-blocking. A suspend function must use an asynchronous API or move blocking work to an appropriate dispatcher.

21. Best Practices

  • Use suspend functions for one-shot Retrofit requests and Room operations.
  • Use Room Flow for database queries that should update automatically.
  • Keep networking and database coordination inside the repository layer.
  • Use viewModelScope for work tied to a ViewModel's lifecycle.
  • Do not perform network or database operations directly on the main thread.
  • Preserve cached content when refreshes fail if the application supports offline access.
  • Represent loading, success, and error states explicitly.
  • Rethrow CancellationException rather than treating it as a normal error.
  • Avoid starting duplicate refresh jobs unnecessarily; consider debouncing or request coordination where appropriate.
  • Use transactions for related database updates that must be atomic.
  • Configure database migrations instead of relying on destructive resets for production data.
  • Never store passwords, API secrets, or private credentials in source code.
  • Use dependency injection or clearly managed factories for Retrofit, Room, and repository instances.
  • Write unit tests for repository logic and integration tests for persistence behavior.

22. Common Mistakes and Troubleshooting

Problem Why It Happens Solution
Retrofit request does not compile with suspend Incompatible or outdated dependency configuration Check Retrofit version, Kotlin setup, and service method signature.
Room rejects a DAO function Unsupported return type or missing annotation Check the query, DAO annotations, and Room version.
UI does not refresh after saving The screen is not collecting the correct Flow or observes a different database instance Verify DAO query, database instance, and lifecycle-aware collection.
Cached products disappear after refresh failure The app clears local rows before confirming the network response Fetch first and use a transaction only after successful response validation.
UI freezes during a request Blocking work is executed on the main thread Use coroutine-aware APIs and move custom blocking operations to an appropriate dispatcher.
Errors appear as generic messages All exceptions are mapped to the same text Classify HTTP, network, parsing, and database failures.
Database migration crashes Schema version changed without a valid migration path Add and test migrations for supported upgrade paths.
Multiple requests run unexpectedly Refresh is triggered repeatedly Coordinate refresh jobs and disable or debounce repeated actions where appropriate.

23. Practical Exercise

Exercise: Build an Android product catalogue that downloads data from a REST API and stores it in Room using Coroutines.

Requirements

  1. Create a product data class for the API response.
  2. Create a Room entity, DAO, and database.
  3. Configure Retrofit with a valid product API endpoint.
  4. Define a suspend GET method in the Retrofit service.
  5. Build a repository that fetches and stores products.
  6. Expose Room results through a Flow.
  7. Use a ViewModel to coordinate refresh operations.
  8. Display a loading indicator while refreshing.
  9. Display saved products when available.
  10. Show an error message and provide a retry action when a request fails.
  11. Test the screen with the network disabled after products have been saved.

Expected Result

The application should fetch products when a refresh is requested, save them in the local database, and display the database contents through a Flow. If a later network request fails, the previously cached products should remain available.

24. Mini Project: Offline-Ready Product Catalogue

Extend the exercise into a small portfolio project with these features:

  • Product list with title, description, and price.
  • Pull-to-refresh or a manual refresh button.
  • Local Room persistence.
  • Loading and error states.
  • Search products using a Room query.
  • Product detail screen using the locally stored record.
  • Network failure handling and retry.
  • Automated tests for repository and DAO behavior.

This project demonstrates knowledge of REST API integration, Coroutines, database persistence, lifecycle-aware UI state, and repository-based architecture.

25. Interview Questions and Answers

Q1. What is the purpose of Kotlin Coroutines in Android?

Answer: Coroutines help execute asynchronous tasks without blocking the main thread and provide structured cancellation and readable asynchronous code.

Q2. Why is a suspend function used in Retrofit?

Answer: Retrofit supports suspend service methods, allowing requests to be written in a sequential style without manually managing callbacks.

Q3. What is the role of Room?

Answer: Room provides a structured abstraction over SQLite, including entities, DAOs, query validation, and integration with Coroutines and Flow.

Q4. What is the difference between suspend and Flow in a Room DAO?

Answer: A suspend query typically returns one result when called. A Flow query emits results over time as the observed database data changes.

Q5. Why use a repository when Retrofit and Room are already available?

Answer: The repository centralizes data access and synchronization logic, hides implementation details from the ViewModel, and makes testing easier.

Q6. What happens when Room data changes?

Answer: A Flow query observing the affected table can rerun and emit an updated result to active collectors.

Q7. Should all Retrofit and Room calls be wrapped in Dispatchers.IO?

Answer: Not necessarily. Their coroutine-aware suspend APIs already handle asynchronous execution. Custom blocking I/O may require an explicit dispatcher switch.

Q8. What is offline-first architecture?

Answer: It is an approach where the local database acts as the main source observed by the UI while remote data refreshes the local cache.

Q9. Why are database transactions important?

Answer: Transactions make a group of related database operations atomic, preventing partially applied updates from becoming visible as a completed change.

Q10. How should coroutine cancellation be handled?

Answer: Allow cancellation to propagate. If catching a broad exception, rethrow CancellationException rather than converting it into a regular error state.

Q11. What is the benefit of exposing Flow from a repository?

Answer: It allows the UI to observe local database updates without manually reloading the data after every write.

Q12. How can the application retain data after a network failure?

Answer: Observe Room as the source of displayed data and avoid deleting cached rows until a valid replacement response has been received and validated.

26. Quick Revision

  • Coroutines: Coordinate asynchronous operations.
  • Retrofit: Sends HTTP requests and converts API responses.
  • Room: Persists structured data locally.
  • Suspend: Suitable for one-shot asynchronous API and database operations.
  • Flow: Emits ongoing database query results.
  • Repository: Coordinates remote and local data sources.
  • ViewModel: Manages screen-level state and lifecycle-aware work.
  • Transaction: Groups database operations atomically.
  • Offline-first: Lets the UI rely on saved local data while the network refreshes it.
  • Cancellation: Prevents unnecessary work from continuing after its owning scope is cancelled.

27. Official Learning Resources

28. Conclusion

Integrating Coroutines with Retrofit and Room is a foundational skill for developing responsive, maintainable Android applications. Retrofit handles communication with remote APIs, Room provides reliable local persistence, and Coroutines coordinate asynchronous operations. By exposing Room data through Flow and managing refresh operations in a repository and ViewModel, developers can build screens that remain responsive, update automatically, and continue displaying cached information when network access is unavailable.

For more Android development learning opportunities, visit JustAcademy's Android App Development course, register for a course demo, or explore the JustAcademy website.

Previous Next
whatsapp